Day 19 的 MULTI 排隊的時候指令還沒執行,拿不到值,沒辦法「先看有沒有庫存、有才扣」。
Lua 是一種很小的程式語言,Redis 裡面就有內建。把判斷跟扣庫存寫成一段 Lua 腳本丟給 Redis,整段跑完之前,別人的指令都插不進來。之前說過 Redis 是單執行緒,一段腳本對它來說就跟一個指令一樣。
今天用扣庫存當例子,之後的秒殺會直接用到。
進 redis-cli,用腳本設定庫存(在主機的終端機下):
docker exec -it redis30days redis-cli
EVAL "return redis.call('SET', KEYS[1], ARGV[1])" 1 stock:1 3
GET stock:1

redis.call 就是在腳本裡下 Redis 指令。stock 前面的 1 是 key 的個數,所以 stock:1 放進 KEYS[1],剩下的 3 放進 ARGV[1](Lua 從 1 開始數)。
key 跟參數分開放,是因為 Redis 要先知道腳本會動到哪些 key。Day 18 的 Cluster 就是看 KEYS 算 slot,決定要送去哪一台。
腳本裡用到的 key 一律從 KEYS 傳進來,不要寫死在腳本裡,Cluster 下寫死的 key 剛好在別台的話,會噴 Script attempted to access a non local key。
新增 resources/scripts/deduct.lua:
-- Day 20:庫存大於 0 才扣一個,整段跑完之前不會被插隊
-- KEYS[1]:庫存的 key,例如 stock:1
local stock = tonumber(redis.call('GET', KEYS[1])) or 0 -- key 不存在當 0
if stock <= 0 then
return -1 -- 沒庫存,不扣
end
return redis.call('DECR', KEYS[1]) -- 扣一個,回剩幾個
新增一個 StockController 來執行這支腳本:
/** 腳本放在 resources/scripts/,Lua 回的整數對到 Long */
private static final RedisScript<Long> DEDUCT =
RedisScript.of(new ClassPathResource("scripts/deduct.lua"), Long.class);
/** 買一個:回扣完剩幾個,沒庫存回 -1 */
@PostMapping("/buy")
public Long buy(@RequestParam long id) {
return redis.execute(DEDUCT, List.of("stock:" + id)); // List 裡的就是 KEYS
}
重新啟動 Java,開另一個終端機,盯著 Redis 收到的指令(在主機的終端機下):
docker exec redis30days redis-cli MONITOR | grep -E "EVAL|lua"
回到 redis-cli 那個終端機,exit 出來,連買四次(在主機的終端機下):
curl -X POST "localhost:8080/stock/buy?id=1" # 回 2
curl -X POST "localhost:8080/stock/buy?id=1" # 回 1
curl -X POST "localhost:8080/stock/buy?id=1" # 回 0
curl -X POST "localhost:8080/stock/buy?id=1" # 回 -1,沒庫存了
庫存 3 個,第四次回 -1,沒有被扣成負的。
看一下 MONITOR:

EVALSHA,只送腳本的編號(SHA1)。Redis 沒看過這支腳本,回 NOSCRIPT,Spring 才改送 EVAL 把整支腳本傳過去(後面那一長串就是腳本,中文被轉成 \x 開頭的編碼)EVALSHA,腳本再長也只傳 40 個字的編號[0 lua] 是腳本裡下的指令:前三次 GET 完接著 DECR,第四次庫存是 0,GET 完就回 -1 了再進 redis-cli,把 Day 19 的轉帳寫成腳本,B 一樣是小數(在主機的終端機下):
docker exec -it redis30days redis-cli
MSET balance:A 100 balance:B 80.5
EVAL "redis.call('DECRBY', KEYS[1], ARGV[1]) redis.call('INCRBY', KEYS[2], ARGV[1])" 2 balance:A balance:B 30
MGET balance:A balance:B

A 被扣成 70,B 還是 80.5。Lua 腳本跟 MULTI 一樣不會回滾,跑到一半出錯就停在那裡,前面寫的不會還原。
腳本跑的時候,其他人都要等。一樣在 redis-cli 裡,跑一個不會結束的腳本:
EVAL "while true do end" 0
開另一個終端機,買一次(在主機的終端機下):
curl -X POST "localhost:8080/stock/buy?id=1"


/stock/buy 回 500,Java 的 console 噴 RedisBusyException: BUSY Redis is busy running a script。腳本跑超過 5 秒(busy-reply-threshold 預設 5000 毫秒),Redis 才會回其他連線 BUSY,在那之前都是卡著等。
再把腳本停掉(在主機的終端機下):
docker exec redis30days redis-cli SCRIPT KILL

SCRIPT KILL 之後,redis-cli 那邊收到 Script killed by user,Redis 就恢復了。
換一個先寫一筆資料、再跑不停的腳本:
EVAL "redis.call('SET', KEYS[1], 1) while true do end" 1 lua:busy
一樣到另一個終端機(在主機的終端機下):
docker exec redis30days redis-cli SCRIPT KILL
docker exec redis30days redis-cli SHUTDOWN NOSAVE
docker compose up -d


SCRIPT KILL 回 UNKILLABLE:腳本已經寫過資料,這時候停下來就只做了一半,Redis 不讓你停。只剩兩條路,等它跑完,或是 SHUTDOWN NOSAVE 把整台 Redis 關掉後再 docker compose up -d 開回來。
KEYS * 一樣,所有人都卡住。超過 5 秒還能 SCRIPT KILL,寫過資料的連 KILL 都不行,只能把 Redis 關掉。相關範例程式碼可以參考 https://github.com/gary880306/redis-30days/tree/dev
明天回到 Day 4 的商品快取,來看如果一直有人查不存在的商品,會發生什麼事![]()